Skip to content

fix(pds-core): identify generated-handle accounts by email - #244

Draft
aspiers wants to merge 4 commits into
mainfrom
split148/enrichment-tooltip
Draft

fix(pds-core): identify generated-handle accounts by email#244
aspiers wants to merge 4 commits into
mainfrom
split148/enrichment-tooltip

Conversation

@aspiers

@aspiers aspiers commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Split 4 of 4 from #148 — the user-visible change the other three exist to support. #241, #242 and #243 have all merged, so this now targets main directly and contains only its own two commits.

Closes #143.

What it does

When a handle is server-generated, the user never chose it and cannot recognise it, so leading with it on the chooser and consent screens tells them nothing about which account they are approving. This shows the email as the primary identifier in that case, and keeps the handle reachable through an information icon — the handle is still the public name AT Protocol apps display, so it can't just be dropped.

UI Changes

Consent screen when handle mode is picker or picker-with-random

when handle mode is picker or picker-with-random

Consent screen when handle mode is random, showing email as the primary identifier

when handle mode is random — email leads, and the handle stays available through the information icon

Tightened DOM matching

The previous heuristic matched handle-shaped text anywhere on the page, which put it in reach of legal copy, connected-app rows, device rows and any prose that happened to contain something handle-like.

Rewriting is now scoped to elements this code can actually identify: an exact match against a known account's preferred_username, @handle or sub, on a b/strong element, inside one of the recognised consent phrasings.

This remains coupled to upstream's DOM copyhasApprovedConsentIdentityContext hard-matches strings like "wants to access your" and "account". The fixture tests will not catch an upstream copy change, so this is worth re-verifying against the real bundle after any upstream upgrade. It collides with #233 for exactly this reason.

Accessibility

Replaces the title= tooltip — which screen readers and touch devices handle poorly — with a real role="tooltip" wired through aria-describedby. Hover, focus and tap all open it; tap/click pins it; Escape dismisses it, as WCAG 1.4.13 requires for content shown on hover or focus.

Also fixed

The two upstream globals shared a single capture variable. Upstream sets __sessions or __deviceSessions depending on the route, and whichever wrote last clobbered the other.

Coverage

Extends enrichment to the consent pages, /account, /account/:did and the preview routes. e2e assertions now check the accessible description rather than the old title attribute.

Verification

typecheck, lint, format clean; 76 test files / 1181 tests pass.

Series

  1. refactor(pds-core): extract OAuth client-id resolution from CSS middleware #241 — extract OAuth client-id resolution ✅ merged
  2. fix(auth): gate email sign-in until its handlers are installed #242 — gate email sign-in until handlers are installed ✅ merged
  3. feat(auth): sign epds_handle_mode through the callback hop #243 — sign epds_handle_mode through the callback hop ✅ merged
  4. fix(pds-core): identify generated-handle accounts by email #244 ← this PR — identify generated-handle accounts by email

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Sign-in, consent, and account-management screens now prioritize email addresses over generated handles.
    • Public handles remain available through accessible information tooltips and descriptions.
    • Random handles are no longer briefly shown as primary identifiers during approval.
    • Account selectors now provide clearer labels and improved accessibility for hidden handle details.
  • Bug Fixes
    • Improved handling of account identity display across consent and session-reuse flows.
    • Added more reliable client metadata resolution for preview and consent experiences.

Copilot AI lite review requested due to automatic review settings August 5, 2026 17:09
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 671c4e7

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@aspiers, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a6adf9c1-96ee-4116-943c-2fb4c617baeb

📥 Commits

Reviewing files that changed from the base of the PR and between 9277fc1 and 671c4e7.

📒 Files selected for processing (2)
  • packages/pds-core/src/__tests__/chooser-enrichment.test.ts
  • packages/pds-core/src/chooser-enrichment.ts
📝 Walkthrough

Walkthrough

The PR adds email-first identity presentation across consent, chooser, and account-management views. It resolves handle mode from request and client metadata, exposes generated handles through accessible descriptions and tooltips, and adds comprehensive unit, integration, and end-to-end coverage.

Changes

Email-first identity presentation

Layer / File(s) Summary
Handle-mode resolution and preview propagation
packages/pds-core/src/lib/preview-shared.ts, packages/pds-core/src/lib/preview-chooser.ts, packages/pds-core/src/lib/preview-consent.ts, packages/pds-core/src/__tests__/preview-*.test.ts
Shared resolution validates query and client metadata values. Consent previews expose the resolved mode and enrichment script before session hydration.
Cross-page enrichment and accessible identity markup
packages/pds-core/src/chooser-enrichment.ts, packages/pds-core/src/index.ts
Enrichment covers multiple session sources, consent views, chooser rows, account selectors, and account pages. Random handles use accessible descriptions, identity tooltips, email labels, and updated ARIA names. PAR request_uri resolution supports client metadata lookup with structured debug logging.
Identity presentation regression coverage
packages/pds-core/src/__tests__/chooser-enrichment.test.ts, e2e/step-definitions/*, features/*, .changeset/email-first-account-presentation.md
Tests verify identity content, page scoping, tooltip accessibility, random-mode handle disclosure, rerenders, request-URI fallback behavior, and logging redaction. E2E scenarios cover consent and account-picker behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OAuthClient
  participant ConsentPreview
  participant ChooserEnrichment
  participant AccountChooser
  OAuthClient->>ConsentPreview: OAuth request with handle mode context
  ConsentPreview->>ChooserEnrichment: resolved mode and session hydration data
  ChooserEnrichment->>AccountChooser: email-first identity markup
  AccountChooser->>ChooserEnrichment: focus identity information button
  ChooserEnrichment-->>AccountChooser: accessible handle tooltip
Loading

Possibly related PRs

Suggested reviewers: s-adamantine

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: showing email for accounts with generated handles.
Linked Issues check ✅ Passed The changes satisfy issue #143 by extending enrichment, hiding random handles, adding accessible explanations, and covering the required flows with tests.
Out of Scope Changes check ✅ Passed The changes remain within issue #143 scope, including handle-mode resolution, preview routes, request-URI logging protection, and related regression tests.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch split148/enrichment-tooltip

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
epds-demo Ready Ready Preview Aug 11, 2026 9:40pm

Request Review

@railway-app

railway-app Bot commented Aug 5, 2026

Copy link
Copy Markdown

🚅 Deployed to the ePDS-pr-244 environment in ePDS

Service Status Web Updated (UTC)
@certified-app/pds-core ✅ Success (View Logs) Web Aug 11, 2026 at 9:40 pm
@certified-app/auth-service ✅ Success (View Logs) Web Aug 11, 2026 at 3:45 pm
@certified-app/demo untrusted ✅ Success (View Logs) Web Aug 5, 2026 at 5:13 pm
@certified-app/demo ✅ Success (View Logs) Web Aug 5, 2026 at 5:13 pm

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR completes the “email-first identity for generated handles” work in pds-core by extending the chooser enrichment script to correctly identify accounts by email (when handle mode is random) across OAuth chooser/consent and account-management surfaces, while keeping the public handle available via accessible UI affordances.

Changes:

  • Extends the injected enrichment script to cover consent identity text, /account list rows, and /account/:did account selector UI, and fixes the upstream __sessions/__deviceSessions capture clobbering.
  • Updates preview routes (/preview/chooser, /preview/consent) to exercise the same handle-mode resolution path and inject the enrichment script ahead of hydration.
  • Expands unit/e2e coverage to assert the new accessible-description behavior (instead of title=) and adds consent scenarios.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/pds-core/src/lib/preview-shared.ts Adds shared resolveQueryHandleMode() so both preview routes resolve handle mode consistently.
packages/pds-core/src/lib/preview-consent.ts Injects handle-mode meta + enrichment script into consent preview, and wires handle mode into the fixture.
packages/pds-core/src/lib/preview-chooser.ts Removes duplicated handle-mode resolution and uses the shared preview resolver.
packages/pds-core/src/index.ts Passes PAR client-id resolver + logger into chooser enrichment middleware wiring.
packages/pds-core/src/chooser-enrichment.ts Major enrichment-script expansion (consent/account surfaces, accessible descriptions/tooltips) and request-context handle-mode resolution via PAR request_uri.
packages/pds-core/src/tests/preview-consent.test.ts Verifies consent preview injects handle-mode meta + enrichment script before hydration and supports ?epds_handle_mode=random.
packages/pds-core/src/tests/preview-chooser.test.ts Verifies enrichment script precedes hydration and fixture identities are present.
packages/pds-core/src/tests/chooser-enrichment.test.ts Large expansion of deterministic DOM tests for consent/account enrichment and accessibility behavior.
features/session-reuse-bugs.feature Updates feature wording to match accessible-description behavior.
features/consent-screen.feature Adds consent scenarios covering tooltip identity behavior for default vs random handle modes.
e2e/step-definitions/session-reuse-bugs.steps.ts Updates e2e assertions from title= tooltip to aria-describedby hidden-handle descriptions.
e2e/step-definitions/consent.steps.ts Adds e2e steps validating consent email-first identity and tooltip-exposed handle/email.
.changeset/email-first-account-presentation.md Adds end-user-facing release notes for email-first identity + accessible info icon behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/pds-core/src/chooser-enrichment.ts
@aspiers
aspiers force-pushed the split148/handle-mode-plumbing branch from daa7cea to 84de1ac Compare August 5, 2026 17:25
aspiers added a commit that referenced this pull request Aug 5, 2026
Same leak as the CSS middleware: request_uri is a short-lived bearer
reference to the PAR entry, so logging its value makes it replayable.

Raised by Copilot on #244.

The existing rejection test covered the client_id path, where there is
no request_uri to leak, so it asserted the field was undefined without
exercising the branch that carries a value. Fix its assertion and add
a test on the request_uri path that pins the value out of the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aspiers
aspiers force-pushed the split148/enrichment-tooltip branch from e7a7a67 to 6afaf71 Compare August 5, 2026 17:25
@railway-app
railway-app Bot temporarily deployed to ePDS / ePDS-pr-244 August 5, 2026 17:25 Destroyed
@aspiers

aspiers commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 6afaf71. Valid — same class of leak as #241, different call site.

-          { err, requestUri: query.request_uri, queryMode },
+          {
+            err,
+            hasRequestUri: typeof query.request_uri === 'string',
+            queryMode,
+          },

The existing rejection test here looked like coverage but wasn't: it exercised the client_id path, where there is no request_uri to leak, and asserted the field was undefined. So the branch that actually carries a value was never tested. Fixed that assertion and added a test on the request_uri path that pins the value out of the log.

@aspiers
aspiers force-pushed the split148/handle-mode-plumbing branch from 84de1ac to 14dd1b4 Compare August 6, 2026 09:16
aspiers added a commit that referenced this pull request Aug 6, 2026
Same leak as the CSS middleware: request_uri is a short-lived bearer
reference to the PAR entry, so logging its value makes it replayable.

Raised by Copilot on #244.

The existing rejection test covered the client_id path, where there is
no request_uri to leak, so it asserted the field was undefined without
exercising the branch that carries a value. Fix its assertion and add
a test on the request_uri path that pins the value out of the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aspiers
aspiers force-pushed the split148/enrichment-tooltip branch from 6afaf71 to 87b55c7 Compare August 6, 2026 09:16
@railway-app
railway-app Bot temporarily deployed to ePDS / ePDS-pr-244 August 6, 2026 09:16 Destroyed
@aspiers
aspiers force-pushed the split148/handle-mode-plumbing branch 2 times, most recently from bf37444 to 534647d Compare August 11, 2026 15:38
aspiers added a commit that referenced this pull request Aug 11, 2026
Same leak as the CSS middleware: request_uri is a short-lived bearer
reference to the PAR entry, so logging its value makes it replayable.

Raised by Copilot on #244.

The existing rejection test covered the client_id path, where there is
no request_uri to leak, so it asserted the field was undefined without
exercising the branch that carries a value. Fix its assertion and add
a test on the request_uri path that pins the value out of the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aspiers
aspiers force-pushed the split148/enrichment-tooltip branch from 87b55c7 to 63a3a66 Compare August 11, 2026 15:43
Base automatically changed from split148/handle-mode-plumbing to main August 11, 2026 20:28
aspiers and others added 2 commits August 11, 2026 20:52
When a handle is server-generated the user never chose it and cannot
recognise it, so leading with it on the chooser and consent screens
tells them nothing about which account they are approving. Show the
email as the primary identifier in that case and keep the handle
reachable through an information icon, since the handle is still the
public name AT Protocol apps display.

Scope the DOM rewriting to identity elements this code can actually
identify: an exact match against a known account's handle, @handle or
sub, on a b/strong element, in one of the recognised consent phrasings.
The previous heuristic matched handle-shaped text anywhere on the page,
which put it in reach of legal copy, connected-app rows and device
rows that happened to contain something handle-like.

Replaces the title= tooltip, which screen readers and touch devices
handle poorly, with a real role="tooltip" wired through
aria-describedby. Escape dismisses it, as WCAG 1.4.13 requires for
content shown on hover or focus.

Also fixes the two globals sharing a single capture variable: upstream
sets __sessions or __deviceSessions depending on the route, and
whichever wrote last clobbered the other.

Extends coverage to the consent pages, /account, /account/:did and the
preview routes, and updates the e2e assertions to check the accessible
description rather than the old title attribute.

Split out of #148.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same leak as the CSS middleware: request_uri is a short-lived bearer
reference to the PAR entry, so logging its value makes it replayable.

Raised by Copilot on #244.

The existing rejection test covered the client_id path, where there is
no request_uri to leak, so it asserted the field was undefined without
exercising the branch that carries a value. Fix its assertion and add
a test on the request_uri path that pins the value out of the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coveralls-official

coveralls-official Bot commented Aug 11, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 31538892005

Coverage increased (+0.02%) to 60.552%

Details

  • Coverage increased (+0.02%) from the base build.
  • Patch coverage: 18 of 18 lines across 3 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 3124
Covered Lines: 1890
Line Coverage: 60.5%
Relevant Branches: 1951
Covered Branches: 1183
Branch Coverage: 60.64%
Branches in Coverage %: Yes
Coverage Strength: 10.1 hits per line

💛 - Coveralls

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (7)
packages/pds-core/src/chooser-enrichment.ts (2)

482-529: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Hoist the per-tick helper declarations out of enrich().

accountListAnchor, emptyAccountTitle, and enrichAccountListRow are declared inside enrich(). enrich() runs from the MutationObserver callback on Line 673, which observes document.documentElement with subtree: true. enrich() itself mutates the DOM: it appends spans, sets style properties, and sets attributes. Each of those mutations schedules another tick.

The dataset guards make repeat ticks cheap per node, but each tick still allocates three closures and performs a full TreeWalker pass over #root, plus the querySelectorAll calls in enrichAccountSelector and hideSignup. This PR increases the per-tick cost with the added consent walker and selector walker.

Move the three helpers to the same scope as the other top-level script functions. Consider also coalescing observer ticks with requestAnimationFrame so a burst of self-inflicted mutations produces one pass.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pds-core/src/chooser-enrichment.ts` around lines 482 - 529, Move
accountListAnchor, emptyAccountTitle, and enrichAccountListRow out of enrich()
into the same top-level scope as the other script functions, preserving their
existing behavior and dependencies. Keep enrich() focused on orchestration so
observer-triggered ticks do not recreate these closures; optionally coalesce
MutationObserver-triggered enrich passes with requestAnimationFrame if supported
by the existing flow.

294-300: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Make consent identity enrichment locale-independent.

@atproto/oauth-provider-ui@0.4.3 falls back to navigator.languages when uiLocales is absent. French and Japanese consent copy therefore bypasses hasApprovedConsentIdentityContext. Match a structurally identified account element while retaining the exact known handle, @handle, or DID check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pds-core/src/chooser-enrichment.ts` around lines 294 - 300, Update
hasApprovedConsentIdentityContext to identify the consent account element
structurally rather than matching only English phrases in context.before, so
localized French and Japanese copy is supported. Preserve the existing exact
checks for a known handle, `@handle`, or DID, and keep the context.after ===
'account' requirement.
packages/pds-core/src/__tests__/preview-consent.test.ts (1)

83-103: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Isolate EPDS_DEFAULT_HANDLE_MODE in this test.

resolveHandleMode reads this variable at render time. Stub it to an empty value before rendering and call vi.unstubAllEnvs() in the existing afterEach; vi.restoreAllMocks() does not restore environment stubs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pds-core/src/__tests__/preview-consent.test.ts` around lines 83 -
103, Update the test around createPreviewConsentHandler to stub
EPDS_DEFAULT_HANDLE_MODE to an empty value before rendering, ensuring
resolveHandleMode reads the isolated environment. Extend the existing afterEach
cleanup to call vi.unstubAllEnvs() alongside vi.restoreAllMocks().</codeેન
packages/pds-core/src/__tests__/chooser-enrichment.test.ts (2)

1939-1955: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Make the redaction guard see error messages.

JSON.stringify serializes an Error instance to {}, because message and stack are non-enumerable. The err field in the logged payload is therefore invisible to the not.toContain(requestUri) guard. The test cannot catch a regression where the request URI reaches the log through an error message.

Normalize errors before stringifying so the guard covers the whole payload.

🔒️ Proposed hardening
     expect(debug).toHaveBeenCalledWith(
       expect.objectContaining({ hasRequestUri: true }),
       'chooser-enrichment: failed to resolve handle mode from OAuth request context',
     )
-    expect(JSON.stringify(debug.mock.calls)).not.toContain(requestUri)
+    const serialized = JSON.stringify(debug.mock.calls, (_key, value) =>
+      value instanceof Error ? `${value.name}: ${value.message}` : value,
+    )
+    expect(serialized).not.toContain(requestUri)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pds-core/src/__tests__/chooser-enrichment.test.ts` around lines 1939
- 1955, Update the redaction assertion in the “logs request_uri presence but
never its value” test to normalize Error values, including their message and
stack, before stringifying debug.mock.calls. Keep the existing request URI
absence check, but ensure it inspects the complete logged payload so a URI
embedded in an error message is detected.

1842-1855: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that resolveClientMetadata is not called after client-id resolution rejects.

The test supplies resolveClientMetadata: vi.fn() but never asserts on it. vi.fn() returns undefined, so a regression that calls resolveClientMetadata(undefined) after the rejection still yields the fallback meta tag and the test still passes. Assert the skip explicitly.

♻️ Proposed assertion
   it('degrades silently when request_uri client-id resolution rejects', async () => {
+    const resolveClientMetadata = vi.fn()
+
     const written = await captureWrittenHtml(
       {
-        resolveClientMetadata: vi.fn(),
+        resolveClientMetadata,
         resolveClientIdFromRequestUri: () =>
           Promise.reject(new Error('request expired')),
       },
       { request_uri: 'urn:ietf:params:oauth:request_uri:req-123' },
     )
 
+    expect(resolveClientMetadata).not.toHaveBeenCalled()
     expect(written).toContain(
       '<meta name="epds-handle-mode" content="picker-with-random">',
     )
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pds-core/src/__tests__/chooser-enrichment.test.ts` around lines 1842
- 1855, Update the test case around captureWrittenHtml to assert that the
supplied resolveClientMetadata mock was not called when
resolveClientIdFromRequestUri rejects. Keep the existing fallback meta-tag
assertion and use the existing resolveClientMetadata mock for the explicit
call-count check.
e2e/step-definitions/consent.steps.ts (1)

69-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make openIdentityTooltip idempotent.

The tooltip control toggles on click. Line 82 asserts aria-expanded is 'false' before line 90 clicks it. Two Then steps call this helper: "the consent identity tooltip exposes the public AT Protocol handle" and "the consent identity tooltip exposes the account email". No scenario chains both steps today, so the helper works. If a future scenario chains them, the second call finds the tooltip already open and the line 82 precondition fails with a misleading message.

Open the tooltip only when it is closed.

♻️ Proposed change
   await expect(tooltipControl).toHaveAttribute('type', 'button')
-  await expect(tooltipControl).toHaveAttribute('aria-expanded', 'false')
   const describedBy = await tooltipControl.getAttribute('aria-describedby')
   expect(describedBy?.trim()).toBeTruthy()
   if (!describedBy?.trim()) {
     throw new Error('Expected aria-describedby to reference a tooltip')
   }
   const [tooltipId] = describedBy.trim().split(/\s+/)
 
-  await tooltipControl.click()
+  // Idempotent: a scenario may chain several tooltip assertions, and the
+  // control toggles rather than latches open.
+  if ((await tooltipControl.getAttribute('aria-expanded')) !== 'true') {
+    await tooltipControl.click()
+  }
   await expect(tooltipControl).toHaveAttribute('aria-expanded', 'true')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/step-definitions/consent.steps.ts` around lines 69 - 97, Update
openIdentityTooltip so it reads aria-expanded and clicks the tooltip control
only when its current value is not already 'true'. Preserve the existing
closed-state validation for the initial call, while allowing subsequent calls to
reuse the already-open tooltip and continue validating and returning it.
e2e/step-definitions/session-reuse-bugs.steps.ts (1)

440-478: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the hidden-handle description reaches the accessibility tree.

The step proves the description element exists, carries .epds-hidden-handle-description, and holds the expected text. It does not prove that assistive technology can announce it. If the enrichment styled the description with display: none or visibility: hidden, screen readers would announce nothing and this step would still pass. That defeats the accessibility guarantee this PR introduces.

Capture the computed style of the resolved description and assert it is not removed from the accessibility tree.

♻️ Proposed additions
 type HiddenHandleDescriptionRow = {
   describedBy: string | null
   descriptions: {
     id: string
     isHiddenHandleDescription: boolean
+    isAccessible: boolean
     text: string
   }[]
             const descriptions = descriptionIds.map((id) => {
               const describedElement = document.getElementById(id)
+              const style = describedElement
+                ? globalThis.getComputedStyle(describedElement)
+                : null
               return {
                 id,
                 isHiddenHandleDescription:
                   describedElement?.classList.contains(
                     'epds-hidden-handle-description',
                   ) ?? false,
+                // display:none / visibility:hidden remove the node from the
+                // accessibility tree, so aria-describedby would announce nothing.
+                isAccessible:
+                  style !== null &&
+                  style.display !== 'none' &&
+                  style.visibility !== 'hidden',
                 text: describedElement?.textContent?.trim() ?? '',
               }
             })

Then assert it in the loop:

       expect(
         description,
         `Row ${row.rowIndex}: expected aria-describedby to reference an .epds-hidden-handle-description element`,
       ).toBeDefined()
+
+      expect(
+        description?.isAccessible,
+        `Row ${row.rowIndex}: hidden-handle description must stay in the accessibility tree (not display:none / visibility:hidden)`,
+      ).toBe(true)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/step-definitions/session-reuse-bugs.steps.ts` around lines 440 - 478,
Update the evaluateAll callback and its HiddenHandleDescriptionRow result to
capture each resolved description element’s computed display and visibility
values. In the assertion loop consuming these rows, require the hidden-handle
description to have display other than none and visibility other than hidden,
while preserving the existing class and text checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/pds-core/src/chooser-enrichment.ts`:
- Around line 407-409: Update the publicIdentifier construction in both
accessible-name paths near the shown code and the corresponding later site so a
matched DID fallback does not pass through formatPublicHandle or produce an `@did`
value. Only include the formatted handle when preferred_username or an
`@-prefixed` handle is available; otherwise omit the handle segment from the
aria-label while preserving existing username behavior.

---

Nitpick comments:
In `@e2e/step-definitions/consent.steps.ts`:
- Around line 69-97: Update openIdentityTooltip so it reads aria-expanded and
clicks the tooltip control only when its current value is not already 'true'.
Preserve the existing closed-state validation for the initial call, while
allowing subsequent calls to reuse the already-open tooltip and continue
validating and returning it.

In `@e2e/step-definitions/session-reuse-bugs.steps.ts`:
- Around line 440-478: Update the evaluateAll callback and its
HiddenHandleDescriptionRow result to capture each resolved description element’s
computed display and visibility values. In the assertion loop consuming these
rows, require the hidden-handle description to have display other than none and
visibility other than hidden, while preserving the existing class and text
checks.

In `@packages/pds-core/src/__tests__/chooser-enrichment.test.ts`:
- Around line 1939-1955: Update the redaction assertion in the “logs request_uri
presence but never its value” test to normalize Error values, including their
message and stack, before stringifying debug.mock.calls. Keep the existing
request URI absence check, but ensure it inspects the complete logged payload so
a URI embedded in an error message is detected.
- Around line 1842-1855: Update the test case around captureWrittenHtml to
assert that the supplied resolveClientMetadata mock was not called when
resolveClientIdFromRequestUri rejects. Keep the existing fallback meta-tag
assertion and use the existing resolveClientMetadata mock for the explicit
call-count check.

In `@packages/pds-core/src/__tests__/preview-consent.test.ts`:
- Around line 83-103: Update the test around createPreviewConsentHandler to stub
EPDS_DEFAULT_HANDLE_MODE to an empty value before rendering, ensuring
resolveHandleMode reads the isolated environment. Extend the existing afterEach
cleanup to call vi.unstubAllEnvs() alongside vi.restoreAllMocks().</codeેન

In `@packages/pds-core/src/chooser-enrichment.ts`:
- Around line 482-529: Move accountListAnchor, emptyAccountTitle, and
enrichAccountListRow out of enrich() into the same top-level scope as the other
script functions, preserving their existing behavior and dependencies. Keep
enrich() focused on orchestration so observer-triggered ticks do not recreate
these closures; optionally coalesce MutationObserver-triggered enrich passes
with requestAnimationFrame if supported by the existing flow.
- Around line 294-300: Update hasApprovedConsentIdentityContext to identify the
consent account element structurally rather than matching only English phrases
in context.before, so localized French and Japanese copy is supported. Preserve
the existing exact checks for a known handle, `@handle`, or DID, and keep the
context.after === 'account' requirement.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a2971628-d4d2-4851-b956-287089c78113

📥 Commits

Reviewing files that changed from the base of the PR and between 7ade8ca and 9277fc1.

📒 Files selected for processing (13)
  • .changeset/email-first-account-presentation.md
  • e2e/step-definitions/consent.steps.ts
  • e2e/step-definitions/session-reuse-bugs.steps.ts
  • features/consent-screen.feature
  • features/session-reuse-bugs.feature
  • packages/pds-core/src/__tests__/chooser-enrichment.test.ts
  • packages/pds-core/src/__tests__/preview-chooser.test.ts
  • packages/pds-core/src/__tests__/preview-consent.test.ts
  • packages/pds-core/src/chooser-enrichment.ts
  • packages/pds-core/src/index.ts
  • packages/pds-core/src/lib/preview-chooser.ts
  • packages/pds-core/src/lib/preview-consent.ts
  • packages/pds-core/src/lib/preview-shared.ts

Comment thread packages/pds-core/src/chooser-enrichment.ts Outdated
matchAccountIdentifier also matches on account.sub, so the matched text
can be a DID when an account has no preferred_username. Both aria-label
sites and the random-mode tooltip then ran that text through
formatPublicHandle(), which prepends '@' to anything not already starting
with one — yielding "@did:plc:..." and asserting a false identifier type.

This lands in accessible names and tooltip copy, so the wrong claim is
read out to exactly the users this enrichment exists to help.

Add publicIdentifierFor() to skip the '@' decoration for DIDs, and
publicIdentityTooltip() to describe a DID as an identifier rather than a
handle. Both new tests fail against the previous behaviour.

Reported by CodeRabbit on #244; the tooltip site was not in the original
report but shares the same fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JSON.stringify renders an Error as {} because message and stack are
non-enumerable, so the logged `err` field was invisible to the
not.toContain(requestUri) assertion. The test therefore could not fail —
including for the most plausible leak path, a request_uri embedded in the
rejection message of the very lookup this code logs about.

Normalize Errors during serialization so the guard covers the whole
payload. Verified by planting the URI in the rejected Error's message:
the assertion now fails and reports the leak, where before it passed.

Reported by CodeRabbit on #244.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aspiers

aspiers commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

(reply generated by Claude Opus 5 via Claude Code)

Working through the 7 nitpicks in the CodeRabbit review summary (these were in the review body, not posted as inline threads).

Fixed

  • Make the redaction guard see error messages (chooser-enrichment.test.ts:1939-1955) — fixed in 671c4e7. Confirmed the finding: JSON.stringify renders an Error as {} because message and stack are non-enumerable, so not.toContain(requestUri) could never fail. That is the security test for the very leak 9277fc1 fixes, and the most plausible vector — a request_uri inside the rejection message of the lookup being logged — was exactly what it could not see. Verified by planting the URI in the rejected Error: the assertion now fails and reports the leak, where before it passed.

Deferred, with reasoning

  • Make consent identity enrichment locale-independent (chooser-enrichment.ts:294-300) — real issue, deliberately not fixed here. hasApprovedConsentIdentityContext matches English phrasing, so localized consent copy bypasses enrichment and the user sees a random handle instead of their email. Two reasons to defer: fixing it means restructuring the matching logic this PR just deliberately tightened to stop over-matching legal copy and device rows, and Upgrade wrapped atproto PDS to latest upstream (Node 22 + ESM) #233 rewrites this same function for oauth-provider-ui 0.8. Doing it now means doing it twice against markup that is about to change. Better as a follow-up once Upgrade wrapped atproto PDS to latest upstream (Node 22 + ESM) #233 lands, so the structural matcher is written against the new DOM.

  • Hoist per-tick helpers out of enrich() (482-529) — tagged Trivial/performance. The dataset guards already make repeat ticks cheap per node; the remaining cost is three closure allocations per tick. Worth doing alongside the requestAnimationFrame coalescing also suggested, as one focused change rather than bolted onto this PR.

  • The four remaining nitpicks (preview-consent.test.ts:83-103, chooser-enrichment.test.ts:1842-1855, consent.steps.ts:69-97, session-reuse-bugs.steps.ts:440-478) are all test-hygiene improvements tagged Trivial — idempotent tooltip helper, an extra not-called assertion, accessibility-tree assertions. All reasonable; none changes behaviour or coverage of a real failure mode. Happy to take any of them if you would rather they land here.

Both inline threads are addressed and replied to individually: 9277fc1 for the request_uri logging, a3f4d29 for the DID-as-handle bug.

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth enrichment: cover all pages, hide handles in random mode, explain via tooltip

2 participants